7.1 KB225 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { createHighlighter, type Highlighter } from "shiki";
4import { timeAgo } from "@/lib/utils";
5import { getRepoCommits, getRepoDiff } from "@/lib/grove-api";
6import { DiffViewer } from "./diff-viewer";
7
8let highlighter: Highlighter | null = null;
9
10async function getHL() {
11 if (!highlighter) {
12 highlighter = await createHighlighter({
13 themes: ["vitesse-light", "vitesse-dark"],
14 langs: [
15 "typescript", "tsx", "javascript", "jsx", "json", "markdown",
16 "css", "html", "python", "rust", "go", "ruby", "yaml", "toml",
17 "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin",
18 "swift", "lua", "diff", "dockerfile", "makefile", "ini",
19 ],
20 });
21 }
22 return highlighter;
23}
24
25function getLang(filepath: string): string {
26 const filename = filepath.split("/").pop() ?? "";
27 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
28 const map: Record<string, string> = {
29 ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
30 json: "json", md: "markdown", css: "css", html: "html",
31 py: "python", rs: "rust", go: "go", rb: "ruby",
32 yml: "yaml", yaml: "yaml", toml: "toml",
33 sh: "bash", bash: "bash", zsh: "bash",
34 sql: "sql", graphql: "graphql", svg: "xml", xml: "xml",
35 c: "c", cpp: "cpp", h: "c", hpp: "cpp",
36 java: "java", kt: "kotlin", swift: "swift", lua: "lua", diff: "diff",
37 };
38 const nameMap: Record<string, string> = {
39 Dockerfile: "dockerfile", Makefile: "makefile",
40 ".gitignore": "gitignore", ".gitmodules": "ini",
41 };
42 if (filename.startsWith("Dockerfile")) return "dockerfile";
43 if (filename.startsWith("Makefile")) return "makefile";
44 return nameMap[filename] ?? map[ext] ?? "text";
45}
46
47interface Props {
48 params: Promise<{ owner: string; repo: string; sha: string }>;
49}
50
51export async function generateMetadata({ params }: Props): Promise<Metadata> {
52 const { repo, sha } = await params;
53 return { title: `${sha.slice(0, 7)} · ${repo}` };
54}
55
56export interface DiffLine {
57 type: "add" | "del" | "context";
58 content: string;
59 oldNum: number | null;
60 newNum: number | null;
61 html?: string;
62}
63
64export interface DiffHunk {
65 header: string;
66 lines: DiffLine[];
67}
68
69export interface ParsedFile {
70 path: string;
71 hunks: DiffHunk[];
72}
73
74function parseUnifiedDiff(raw: string): DiffHunk[] {
75 const hunks: DiffHunk[] = [];
76 let currentHunk: DiffHunk | null = null;
77 let oldLine = 0;
78 let newLine = 0;
79
80 for (const line of raw.split("\n")) {
81 const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/);
82 if (hunkMatch) {
83 currentHunk = { header: line, lines: [] };
84 hunks.push(currentHunk);
85 oldLine = parseInt(hunkMatch[1]);
86 newLine = parseInt(hunkMatch[2]);
87 continue;
88 }
89 if (!currentHunk) continue;
90 if (line.startsWith("+")) {
91 currentHunk.lines.push({ type: "add", content: line.slice(1), oldNum: null, newNum: newLine++ });
92 } else if (line.startsWith("-")) {
93 currentHunk.lines.push({ type: "del", content: line.slice(1), oldNum: oldLine++, newNum: null });
94 } else if (line.startsWith(" ") || line === "") {
95 currentHunk.lines.push({ type: "context", content: line.slice(1), oldNum: oldLine++, newNum: newLine++ });
96 }
97 }
98 return hunks;
99}
100
101async function getCommit(owner: string, repo: string, sha: string) {
102 const data = await getRepoCommits(owner, repo, sha, { limit: 1 });
103 return data?.commits?.[0] ?? null;
104}
105
106export default async function CommitPage({ params }: Props) {
107 const { owner, repo, sha } = await params;
108
109 const commit = await getCommit(owner, repo, sha);
110
111 if (!commit) {
112 return (
113 <div className="max-w-3xl mx-auto px-4 py-16">
114 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
115 Commit not found
116 </h1>
117 </div>
118 );
119 }
120
121 const gitSha = commit.hash ?? sha;
122 const parentSha = commit.parents?.[0] ?? null;
123 const diffData = parentSha ? await getRepoDiff(owner, repo, parentSha, gitSha) : null;
124
125 const files: ParsedFile[] = [];
126 if (diffData?.diffs) {
127 for (const file of diffData.diffs) {
128 if (file.is_binary) {
129 files.push({ path: file.path, hunks: [] });
130 } else {
131 const hunks = parseUnifiedDiff(file.diff);
132 files.push({ path: file.path, hunks });
133 }
134 }
135 }
136
137 // Syntax-highlight diff lines using shiki
138 try {
139 const hl = await getHL();
140 const loadedLangs = hl.getLoadedLanguages();
141 for (const file of files) {
142 if (file.hunks.length === 0) continue;
143 const lang = getLang(file.path);
144 const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
145 // Collect all content lines in diff order
146 const allLines: DiffLine[] = [];
147 for (const hunk of file.hunks) {
148 for (const line of hunk.lines) {
149 allLines.push(line);
150 }
151 }
152 const code = allLines.map((l) => l.content).join("\n");
153 const highlighted = hl.codeToHtml(code, {
154 lang: effectiveLang,
155 themes: { light: "vitesse-light", dark: "vitesse-dark" },
156 defaultColor: false,
157 });
158 const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/);
159 if (codeMatch) {
160 const htmlLines = codeMatch[1].split("\n");
161 for (let i = 0; i < allLines.length && i < htmlLines.length; i++) {
162 allLines[i].html = htmlLines[i];
163 }
164 }
165 }
166 } catch (e) {
167 console.error("[shiki] Failed to highlight diff:", e);
168 }
169
170 const subject = commit.subject ?? "";
171 const body = commit.body ?? "";
172 const authorName = commit.author?.split("<")[0]?.trim() ?? commit.author;
173
174 return (
175 <div className="px-4 py-6 mx-auto" style={{ maxWidth: "90rem" }}>
176 <div
177 className="mb-6 px-4 py-4"
178 style={{
179 backgroundColor: "var(--bg-card)",
180 border: "1px solid var(--border-subtle)",
181 }}
182 >
183 <h1 className="text-lg mb-1">{subject}</h1>
184 {body && (
185 <pre
186 className="text-sm whitespace-pre-wrap mt-3 mb-3"
187 style={{ color: "var(--text-muted)" }}
188 >
189 {body}
190 </pre>
191 )}
192 <div className="flex flex-wrap items-center gap-4 text-xs" style={{ color: "var(--text-muted)" }}>
193 <span>{authorName}</span>
194 <span>{timeAgo(commit.timestamp)}</span>
195 <span className="font-mono" style={{ color: "var(--text-faint)" }}>
196 {gitSha.slice(0, 12)}
197 </span>
198 {parentSha && (
199 <span>
200 parent{" "}
201 <Link
202 href={`/${owner}/${repo}/commit/${parentSha}`}
203 className="font-mono hover:underline"
204 style={{ color: "var(--accent)" }}
205 >
206 {parentSha.slice(0, 7)}
207 </Link>
208 </span>
209 )}
210 </div>
211 </div>
212
213 {files.length > 0 && (
214 <DiffViewer files={files} owner={owner} repo={repo} gitSha={gitSha} />
215 )}
216
217 {!diffData && !parentSha && (
218 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
219 This is the initial commit — no diff available.
220 </p>
221 )}
222 </div>
223 );
224}
225